Skip to content

fix(hooks): guard-main-checkout reads the path key its routed tool carries - #7686

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-7260-guard-hook-notebook-path
Sep 5, 2026
Merged

fix(hooks): guard-main-checkout reads the path key its routed tool carries#7686
os-zhuang merged 1 commit into
mainfrom
claude/issue-7260-guard-hook-notebook-path

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #7260

What was wrong

.claude/settings.json routes Edit|Write|NotebookEdit at this hook, and the hook extracted
exactly one key, .tool_input.file_path. NotebookEdit does not carry that key — its path
key is notebook_path. Verified against the tool's own documented input schema rather than
assumed: notebook_path (absolute, required) and new_source (required), plus cell_id,
cell_type, edit_mode; there is no file_path anywhere in a NotebookEdit payload.

So every notebook edit produced an empty path, fell into the no-path branch and was judged by
CLAUDE_PROJECT_DIR instead of by the file being written. The jq-less fallback grepped for
the same single key, so it missed it too. The resulting verdict is a constant per session,
wrong in both directions depending only on where the session happens to be rooted: notebook
edits inside a correctly created worktree were refused, and notebook edits into the shared
primary checkout were waved through
.

The fix

The hook now carries one tool-to-path-key table and reads the key the routed tool actually
carries, in the jq path and in the text-scan fallback alike:

known_path_keys='Edit=file_path Write=file_path MultiEdit=file_path NotebookEdit=notebook_path'
  • A tool the table names is read for its own key.
  • A tool the table does not name is not routed here by the matcher, so it keeps today's
    permissive read: any known key it happens to carry, else the project-dir fallback.
  • A tool the table does name that carries no path under its own key is schema drift,
    not a missing path. It blocks (exit 2) with a message naming the tool and the key it wanted,
    instead of silently falling back to a verdict about the session. That was the seat's ruling
    on the frame's contract-tightening axis: tolerance here is exactly what hides the error.

The matcher and the table are now a checked relation

This was triage's acceptance condition — "a tool listed in the matcher" and "a path key the
extractor knows" must be a checkable relation, not one more //. The self-test's wiring
section reads known_path_keys out of the hook, splits the matcher on |, and asserts every
routed tool has a row:

  ok   pair   Edit          -> .tool_input.file_path
  ok   pair   Write         -> .tool_input.file_path
  ok   pair   NotebookEdit  -> .tool_input.notebook_path
  note       MultiEdit has a row in known_path_keys; the matcher does not route it

A row with no matcher entry is the harmless direction and prints a note. A tool routed here
with no row is a failure — so the next tool routed at this hook cannot repeat this defect
silently, which was the whole point of not just adding one more key.

Before to after: the notebook rows

The known-hole section flips to the intended verdicts and its banner is gone. PROJ is
CLAUDE_PROJECT_DIR; every row points it somewhere that would give the wrong answer under a
session-based verdict, so a passing row can only have come from the notebook's own path.

payload PROJ before after correct
NotebookEdit into $MAIN $MAIN block block block (was right answer, wrong reason)
NotebookEdit into $WT $MAIN block allow allow
NotebookEdit into $PLAIN $MAIN block allow allow
NotebookEdit into $MAIN $WT allow block block
NotebookEdit into $WT $WT (not pinned) allow allow
NotebookEdit into $MAIN $PLAIN allow block block
NotebookEdit into $WT $PLAIN (new) allow allow
Edit control, same three files wrong dir each allow/block per path unchanged the two payload shapes now agree
NotebookEdit, new file in a new dir under $MAIN / $WT $PLAIN (new) block / allow the ancestor walk reaches notebooks too

Rows that were pinned as INTENDED and that this change flips

Per the ruling: a row pinned as intended behaviour (not under a known-hole banner) that
contradicts the drift rule gets flipped, listed, and explained. Three places:

  1. tool_name=NotebookEdit into $WT was allow, now block. That row fed a
    file_path-carrying payload under the name NotebookEdit. Under the fix that is a routed
    tool with no path under its own key, i.e. drift. The section's premise — "tool_name is
    never consulted" — is itself falsified by the fix (tool_name now selects the key, though
    the verdict still comes from the path alone), so the section was rewritten rather than
    patched, and it now covers Edit/Write/MultiEdit by file_path, NotebookEdit by
    notebook_path, and an unrouted tool through both keys.
  2. no usable path, CLAUDE_PROJECT_DIR=$WT and =$PLAIN for the probe
    {"tool_name":"Edit","tool_input":{}} were allow, now block.
    Routed tool, no path
    under its key: drift, so the session's root no longer decides.
  3. The CLAUDE_PROJECT_DIR unset section's probe was re-pointed, not flipped. It exists
    to measure the cwd fallback, and that branch still exists for unrouted payloads, so its
    probe is now an unrouted AnythingElse and its three verdicts are unchanged. The routed
    Edit probe moved into the new drift section, where it is asserted as block under all
    three roots.

The remaining known hole in the matrix — the */worktrees/* substring predicate — is
untouched, banner and all.

Self-test counts

before after
objectstack guard-main-checkout.selftest.sh 87 passed, 0 failed 116 passed, 0 failed
objectui guard-main-checkout.selftest.sh 87 passed, 0 failed 116 passed, 0 failed

Run against the old matrix, the new hook produces exactly 9 failures — the 9 rows listed
above and nothing else, in both repos. No unlisted row changed.

The jq-less fallback is exercised, not assumed: the matrix runs the notebook rows, the drift
row and the decoy rows a second time with jq removed from PATH (the existing nojq
fixture), because a fallback that still reads one key sends notebook edits straight back to
the no-path branch whenever jq is away.

Non-vacuity

Each new class was driven red against a mutated copy of the hook (GUARD_MAIN_CHECKOUT_HOOK
points the matrix at it, so the tree is never mutated and there is no restore leg to get
wrong). Each mutation was proved on disk by grepping for the injected and the removed text
before the matrix was run, and the hook blob in the worktree was checked equal to its HEAD
blob. Identical results in both repos:

mutation result
point NotebookEdit's row at file_path 107 passed, 9 failed
delete NotebookEdit's row from known_path_keys 111 passed, 5 failed — including FAIL NotebookEdit is routed to this hook but has no row in known_path_keys, which is the pairing check doing its job
neutralise the drift arm so a routed tool falls back to the project dir 107 passed, 9 failed
control: unmutated hook 116 passed, 0 failed

Cross-repo convergence

The two hooks' executable lines are byte-identical before and after. Diff of the two files
after this change, comments stripped: empty. Full diff, comments included:

106,113d105
< # (the 8-line comment block about the worktree recipe that the objectstack copy already carried)

That is the same eight comment lines the objectstack copy carried before this change, and no
executable line differs. The self-tests differ only where they already did: objectui's porting
header, and the remaining known hole's issue reference pointing at each repo's own card.

Exclusions

  • .claude/settings.json — untouched (the matcher is not changed).
  • the */worktrees/* predicate and its known-hole section — untouched.
  • guard-main-checkout-bash.sh — untouched; it parses .tool_input.command and never sees
    notebook payloads.

Sibling PR carrying the byte-identical executable diff:
objectstack-ai/objectstack branch claude/issue-11810-guard-hook-notebook-path for
objectstack#11810. The two repos move together, as triage required.

Gates

This repo has no dispatch-gates derivation, so the family was found by asking which check
scripts read .claude: grep -l '\.claude' scripts/*.mjs, cross-read against the check:*
scripts in package.json. Exit codes captured by redirecting first, never across a pipe.

gate exit verdict line
node scripts/check-control-bytes.mjs 0 OK (scanned 6258 tracked text file(s); skipped 85 binary).
node scripts/check-shell-escape-residue.mjs 0 OK (5/5 root(s) resolved ... 206 file(s) and 1307 fenced block(s) examined in total)
node scripts/check-governed-queue-guard.mjs --self-test 0 OK check-governed-queue-guard self-test: 132 cases pass
node scripts/check-skills-paths.mjs 0 OK (88/89 stated path(s) resolve across 20 guide file(s); 1 baselined).
node scripts/check-skill-eval-tokens.mjs 0 Red under the chosen oracle: 0 (0 beyond the baseline).
node scripts/check-changeset-presence.mjs 0 see below
node scripts/check-skill-examples.mjs 2 PRECONDITION NOT MET, recorded as such and not counted as a pass

check-skill-examples.mjs exits 2 with the example program was NOT run: the packages it resolves against are not built, and its own source declares
export const SCAN_ROOTS = ['skills', '.claude/skills']. This change set is
.claude/hooks/** — outside both scan roots — so building the ten package closures its
--build-filter names would measure the same tree as main. Recorded as NOT MEASURED with
that reason; CI runs it regardless.

Changeset

This repo has no skip-changeset label, so scripts/check-changeset-presence.mjs decides,
and it was run:

Compared the working tree with a472b0716 (merge-base with origin/main): 2 file(s) changed,
0 of them published source of a package the release covers, 0 of them a manifest whose
published contract moved, 0 under a package changesets ignores, 0 changeset(s) added.
✅  No source or published contract of a released package changed in this range, so no
changeset is owed.

No changeset added, per that verdict.

pnpm lint — NOT MEASURED, by eslint's own configuration, not by a choice of mine.
Asked directly about the two changed files, eslint answers per file:

File ignored because no matching configuration was supplied.

Both changed files are shell, outside eslint's configured surface entirely, so this diff
cannot move any verdict about a file eslint does lint. In the sibling repo the same shell
surface is covered by check:bash32-floor (green over 27 files, .claude/hooks/** among its
scan roots); this repo has no equivalent shell gate, which is a gap worth its own card rather
than something to fix here.

Generated by Claude Code


Generated by Claude Code

guard-main-checkout.sh extracted exactly one key, .tool_input.file_path, while
settings.json routes Edit, Write AND NotebookEdit at it. NotebookEdit carries its
path in notebook_path, so every notebook edit yielded an empty path, took the
no-path branch and was judged by CLAUDE_PROJECT_DIR instead of by the file. That
verdict is a constant per session and wrong in both directions: notebook edits
inside a correctly-created worktree were refused when the session was rooted in the
shared checkout, and notebook edits INTO the shared checkout were waved through when
it was not. The jq-less fallback grepped the same single key and missed it too.

The hook now carries a tool-to-path-key table and reads the key the routed tool
actually carries, in the jq path and in the text-scan fallback alike. A tool the
table does not name keeps the permissive read: any known key it carries, else the
project dir. A tool the table DOES name that carries no path under its own key is
schema drift, not a missing path, and blocks with a message naming the tool and the
key rather than falling back to a verdict about the session.

The matcher and the table are now a checked relation, which was the acceptance
condition: the self-test reads known_path_keys out of the hook and reds if
settings.json routes a tool that has no row in it, so the next tool routed here
cannot repeat this defect silently. A row with no matcher entry prints a note.

The matrix's known-hole section for this defect flips to the intended verdicts and
its banner goes; the unrelated known hole below it is untouched, as are
settings.json, the */worktrees/* predicate and guard-main-checkout-bash.sh.

The hook's executable lines stay byte-identical to the sibling repo's copy: the two
files differ only by the eight comment lines that copy already carried.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

ACCEPT — governed in-seat review of PR #7686, head 6e43f3c8 (skills seat, 2026-09-05T02:4xZ), the objectui half of the cross-repo flight with objectstack PR #15665 (accepted in the same act).

Implemented-by: claude/issue-7260-guard-hook-notebook-path (os-dev subagent, one dev for both repos)
Reviewed-by: session_019RfFHiRCSs3JXLK4cwcfox (skills seat)

Verified in a detached compare worktree at the PR head against merge-base a472b0716, not from the report: two files, .claude/hooks/guard-main-checkout.sh and its self-test. The hook reads the path from the key the routed tool actually carries (one known_path_keys table: file_path for Edit/Write, notebook_path for NotebookEdit), in jq and in the jq-less scan alike, and blocks naming the tool and the missing key when a routed tool's payload carries no path under its own key; the self-test reads the table out of the hook and reds when settings.json routes a tool that has no row — triage's acceptance condition, met mechanically. The KNOWN HOLE #7260 section is replaced by the intended verdicts (block / allow / allow by the notebook's own path); the KNOWN HOLE #7259 section, settings.json and the bash guard are untouched. Seat runs on the head: self-test 116 passed, 0 failed (87 on main); the comments-stripped diff against the objectstack copy is empty — the two guards keep one semantics. No model names, no new card numbers in the hook. CI on 6e43f3c8: 30 check runs, 27 success, 3 skipped, none failing. No changeset is owed (scripts/check-changeset-presence.mjs decided: no released package changed).

Three matrix rows pinned as intended behaviour contradicted the drift ruling and were flipped; each is listed and explained in the PR body — the ruling's consequence, accepted.

Landing regime: governed (.claude/**) ⇒ the PR stays a draft; reviewers os-zhuang + hotlong requested in this same act; human merge. Closes #7260. #7259 (the */worktrees/* predicate) is serial behind this PR with objectstack #11809.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review September 5, 2026 06:08

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Enqueue request — skills seat (session session_019RfFHiRCSs3JXLK4cwcfox, os-steve), 2026-09-05T10:1xZ. @os-zhuang @hotlong: approved and flipped ready at 06:08Z, mergeable_state: clean, CI green on 6e43f3c81, not yet enqueued four hours on. Behind it: objectui #7259 and objectstack #11809 are hard-serial on this file pair (.claude/hooks/guard-main-checkout.sh + its self-test), and #7260 closes with it. Governed ⇒ the seat does not enqueue; an approver's click lands it.


Generated by Claude Code

os-steve commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

维护者速读(skills 席,2026-09-05T13:2xZ)

改了什么:objectui 仓的 worktree 守卫 hook(禁止 agent 在共享主检出上直接改文件)改为按工具读它自带的路径键(Edit / Write / MultiEdit / NotebookEdit 各自的字段),自测 87 → 116;与 objectstack 仓已落地的同一改动(PR #15665)逐字节相同。

为什么改:守卫对没列入的工具读不到路径就会漏放,两仓必须一致,否则同一条纪律在一个仓生效、另一个仓失效。

风险与代价:只改 .claude/hooks/ 两个文件;自测全绿;os-zhuang 06:08Z 已批准并翻 ready,7 小时未入队。落地后解锁 #7259 与 objectstack #11809。

席位意见:建议入队。

你要做的:点「入队」。


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 565f2b6 Sep 5, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7260-guard-hook-notebook-path branch September 5, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants